在早期還是新手程式設計師的我,一直搞不懂事件驅動的程式風格到底有什麼好處,也因此走了一些冤枉路,更糟的是我繞了遠路還不自知。因此希望能藉今天這篇文章,讓同學們能早點體會事件驅動程式設計的好處以及適用時機,幫助同學們的未來能走得更快更遠。
先簡單介紹一下何謂事件驅動(event-driven)?
一般我們處理遊戲的流程,就是按順序一路寫下來,我們可以用類別(class)來幫所有函式分門別類整理好,我們可以用介面(interface)來增加函式參數的彈性與可擴充性,我們還可以用各種設計模式(design pattern)增加解題的效率。雖然有很多方法和工具可資利用,但程式還是得一行一行地跑下來、繞過去、走回來,我們看著程式碼,就可以跟著程式執行的流向,一路看到尾。
事件驅動就不一樣了,這種設計方式從一開始就不打算讓程式照流程跑下來。在事件驅動的設計中,主角是「事件」,程式的走向是圍繞在事件短暫的一生,包括事件的發生,相關資料的收集,事件的發布,以及事件的消亡。是不是感覺就像一則新聞報導的生命週期。
舉個最常見的實例給大伙兒瞧瞧-滑鼠左鍵的點擊事件。
在撰寫遊戲中的滑鼠類別時,如果放棄使用事件驅動的設計方式,那麼程式的走向就可能變成這樣:
在事件驅動的系統中就不是這樣了。事件驅動的流程分成兩個部分,一個是事件自己的流程:
事件驅動的第二個部分是遊戲中其他元件的流程,這邊以玩家角色為例說明它的流程:
同學們能感受到事件驅動帶來的巨大好處了嗎?在事件驅動的流程中,我們完全可以把滑鼠的設計從遊戲中獨立出來。所有遊戲的物件,在需要滑鼠來觸發某些動作時,就去滑鼠那兒訂閱一下事件,更酷的是,在不需要這些事件的時候,比方說角色暈眩時,還能夠取消滑鼠事件的訂閱,這樣的設計是不是就能大幅精簡遊戲中各種滑鼠相關的判斷流程。
哪些東西要採用事件驅動的方式來設計,這個需要經驗才容易判斷,畢竟沒有從這個方法感受到好處過的新手,怎麼會想到要使用它。不過小哈可以先透露一些可能比較適合的時機給同學們參考。
正如剛剛鍵盤的例子,輸入輸出的裝置如果能儘量從遊戲核心中獨立出來,那麼以後要支援不同的裝置,滑鼠換成搖桿,或是從電腦換到手機,都會簡單多了,因為遊戲核心只關心各種按鈕的事件新聞,實際的輸入裝置怎麼實作都可以。
舉例來說,遊戲核心不直接使用鍵盤上的鍵,而是定義一些遊戲使用的輸入鍵,像是「攻擊鍵」、「跳躍鍵」、「喝水鍵」等比較抽像的按鈕,然後從輸入裝置那兒訂閱這些事件。
接著在實作輸入裝置的類別裏,把系統的輸入包裝起來,改送「攻擊鍵」、「跳躍鍵」等事件出去,那麼以後即使換了輸入裝置,或是玩家自訂按鈕布局,對遊戲核心來說也完全不受影響。
介面和遊戲邏輯也最好個別獨立,並使用事件來互相溝通。
比如說玩家的血量變動時,就不要直接去找有哪些介面要跟著更新。使用事件驅動的話,只要發一個血量改動的事件出去就好。和血量有關的遊戲介面應該自己去訂閱角色血量變動的事件,並在收到事件的時候去更新介面。
如此一來,未來增加相關的新介面,或是舊介面被廢棄的時候,就不會影響到遊戲核心的邏輯架構。
瀏覽器內建了EventTarget以及Event這兩個類別,不過我們遊戲中無法借來用,所以我們要自己寫一組類似EventTarget與Event的簡單類別。
EventTarget提供了訂閱、發送等功能,主要由三個函式所構成。
Event是被發送的事件,會帶有事件主題這個屬性。一般在設計事件時,都會繼承這個類別,並在事件內夾帶更多的資料,待會兒我們會實作一個遊戲內發生的事件來說明。
/** 寫一個類似Event的基礎類別 */
class MyEvent {
// 建構子要給事件的主題
constructor(public type: string) {
}
}
/** 寫一個類似EventTarget的類別 */
class MyEventTarget {
/** 儲存和主題對應的訂閱者列表
* {[key: string]: Function[]}是一般物件型別,字串為鍵,函式陣列為值
*/
listenerMap: { [key: string]: Function[] } = {};
/** 訂閱功能(也可以叫addEventListener) */
on(type: string, listener: Function) {
// 取出type對應的訂閱者列表, 找不到就用空陣列
let listenerList = this.listenerMap[type] || [];
// 增加訂閱者
listenerList.push(listener);
// 放回去
this.listenerMap[type] = listenerList;
}
/** 取消訂閱功能(也可以叫removeEventListener) */
off(type: string, listener: Function) {
// 取出type對應的訂閱者列表, 找不到就用空陣列
let listenerList = this.listenerMap[type] || [];
// 找訂閱者
let index = listenerList.indexOf(listener);
// 找到就移除
if (index != -1) {
// 從index的位置移除一個元素
listenerList.splice(index, 1);
}
}
/** 發布事件消息(也可以叫dispatchEvent) */
emit(event: MyEvent) {
// 取出type對應的訂閱者列表, 找不到就用空陣列
let listenerList = this.listenerMap[event.type] || [];
// 把event送給所有的訂閱函式
listenerList.forEach(listener => listener(event));
}
}
事件驅動有很多現成的函式庫可以用,小哈常用的事件驅動類別函式庫在這裏: EventEmitter3
下面我們試用剛剛寫好的工具來完成一個非常簡單的事件驅動流程,給大家體會一下事件驅動是什麼感覺。
/** 先寫一個繼承MyEvent的血量變更事件 */
class HpChangeEvent extends MyEvent {
// 定義這個事件的主題
static TYPE = "hpChange";
// 建構子
constructor(
public actor: Actor, // 發生血量變化的角色
public hp: number // 發生血量變化後的血量值
) {
// Event的建構子需要給主題
super(HpChangeEvent.TYPE);
}
}
/** 寫一個繼承MyEventTarget的角色類別 */
class Actor extends MyEventTarget {
// 用一個非公開的變數儲存血量
// private是關鍵字,代表隱私
private _hp = 100;
// 用getter函式來取血量值
get hp(): number {
return this._hp;
}
// 用setter函式來改變血量值
set hp(value: number) {
// 血量不能低於0
value = Math.max(0, value);
// 檢查血量有無變化
if (this._hp != value) {
// 設為新值
this._hp = value;
// 建立事件,並把相關資料放進去
let event = new HpChangeEvent(this, this._hp);
// 公告天下
this.emit(event);
}
}
}
有了事件和可發布事件的物件,我們就可以來進行實驗了。
/**
* 遊戲核心
*/
// 建立一位角色
let actor = new Actor();
// 寫個函式讓角色在n秒後改鑾deltaHp的血量
function changeHpInSeconds(seconds: number, deltaHp: number) {
// 使用setTimeout延遲動作
setTimeout(
function () {
// 下面這行會用hp的getter取值,加上變動值
// 再用hp的setter存回去
actor.hp += deltaHp;
},
seconds * 1000 // 延遲毫秒數
);
}
// 一秒後減50
changeHpInSeconds(1, -50);
// 兩秒後加10
changeHpInSeconds(2, 10);
// 三秒後減30
changeHpInSeconds(3, -30);
/**
* 遊戲介面:訂閱角色血量改變的事件
*/
actor.on(
HpChangeEvent.TYPE, // 訂閱主題
function (event: HpChangeEvent) { // 收到消息要做的事
console.log("角色的血量變成了 " + event.hp);
}
);
在示範程式中,角色血量的變化獨立於用來顯示血量變化的介面。未來我們把剛剛遊戲介面的程式碼都刪除,改用漂亮的圖案動畫來取代,對遊戲中實際增減角色血量的邏輯也不會有一絲影響。
事件驅動設計法的缺點很明顯的就是無法一眼看清程式的運作流程。如果同樣是遊戲核心的一部分,但為了實現事件驅動而切成小小塊的話,那麼切割時以及設計上就要非常小心地全盤考量,不然未來在除錯上會比較傷腦筋。不過也正因如此,事件驅動的架構會驅使設計師在寫程式時考量得更全面,更能看清每個獨立系統的核心價值。又因為系統被分割成獨立小塊,讓整個專案較容易和同伴分工並進。
事件驅動的另一個小缺點,就是只靠事件來溝通的物件之間,比較無法針對流程的細節最佳化。雖然這聽起來有點令人沮喪,不過被強迫無法最佳化,有時也是一種幸福,因為最佳化的另一個暱稱就叫Bug彩蛋,不知道哪天一個小改動就會破殼而出,咬你一口。
明天我們會繼續在事件驅動的基礎上,實作拖曳物件的介面邏輯,敬請期待吧。